Pandas란 무엇인가?
1.1 Pandas의 정의
Pandas는 파이썬(Python)에서 표(테이블) 형태의 데이터를 쉽고 빠르게 분석하기 위한 오픈소스 라이브러리입니다. 이름은 "Panel Data"의 줄임말에서 유래했으며, 2008년 Wes McKinney가 금융 데이터 분석을 위해 개발했습니다. 현재 파이썬 데이터 과학 생태계에서 가장 핵심적인 라이브러리로 자리잡았습니다.
1.2 왜 Pandas인가?
엑셀로도 데이터를 다룰 수 있지만, 데이터가 수만~수백만 행으로 커지면 엑셀은 버벅거립니다. 또한 반복적인 작업을 자동화하기 어렵습니다. Pandas를 사용하면 다음과 같은 이점이 있습니다.
- 대용량 처리 : 수백만 행의 데이터도 빠르게 처리할 수 있습니다.
- 코드 재현성 : 분석 과정을 코드로 남기므로, 동일한 분석을 언제든 반복할 수 있습니다.
- 풍부한 기능 : 필터링, 그룹핑, 집계, 결합, 피벗 등 데이터 분석에 필요한 거의 모든 기능을 제공합니다.
- 생태계 연동 : NumPy, Matplotlib, Scikit-learn 등 다른 파이썬 라이브러리와 완벽하게 호환됩니다.
- 다양한 파일 형식 : CSV, Excel, JSON, SQL, Parquet 등 거의 모든 데이터 형식을 읽고 쓸 수 있습니다.
1.3 Pandas의 핵심 자료구조
Pandas는 두 가지 핵심 자료구조를 제공합니다. 이 두 가지만 이해하면 Pandas의 80%를 다루는 셈입니다.
| 자료구조 | 차원 | 비유 | 설명 |
|---|---|---|---|
| Series | 1차원 | 엑셀의 한 열(Column) | 인덱스가 붙은 1차원 배열. 하나의 데이터 타입만 가집니다. |
| DataFrame | 2차원 | 엑셀의 시트(Sheet) 전체 | 행(Row)과 열(Column)로 이루어진 2차원 표. 각 열은 Series입니다. |
1.4 Pandas vs 엑셀 vs SQL 비교
| 비교 항목 | Pandas | 엑셀 | SQL |
|---|---|---|---|
| 데이터 크기 | 수천만 행 | ~100만 행 (느림) | 수억 행 (DB 의존) |
| 자동화 | 코드로 완전 자동화 | 매크로/VBA 제한적 | 저장 프로시저 |
| 시각화 | Matplotlib 연동 | 내장 차트 | 별도 도구 필요 |
| 학습 난이도 | 중간 | 쉬움 | 쉬움~중간 |
| 데이터 가공 | 매우 유연 | 제한적 | JOIN/집계 강력 |
설치와 환경 설정
2.1 Pandas 설치
Pandas를 설치하는 방법은 여러 가지가 있습니다. 가장 일반적인 두 가지 방법을 소개합니다.
# pip으로 설치 (가장 일반적)
pip install pandas
# conda로 설치 (Anaconda 환경)
conda install pandas
# 특정 버전 설치
pip install pandas==2.2.0
# 업그레이드
pip install --upgrade pandas
2.2 추천 개발 환경
Pandas를 학습할 때는 코드를 한 줄씩 실행하며 결과를 바로 확인할 수 있는 환경이 가장 좋습니다.
- Jupyter Notebook / JupyterLab : 셀 단위로 코드를 실행하고 결과를 바로 확인. 가장 추천하는 학습 환경입니다.
- Google Colab (colab.research.google.com) : 설치 없이 브라우저에서 바로 실행. Pandas가 이미 설치되어 있습니다.
- VS Code + Jupyter 확장 : VS Code 에디터에서 Jupyter 노트북을 실행할 수 있습니다.
- PyCharm : JetBrains의 파이썬 IDE. 대규모 프로젝트에 적합합니다.
2.3 라이브러리 임포트
Pandas를 사용하기 전에 반드시 import해야 합니다. 관례적으로 pd라는 별칭을 사용합니다.
# Pandas 임포트 (관례: pd)
import pandas as pd
# NumPy도 함께 임포트 (관례: np) — Pandas와 자주 함께 사용
import numpy as np
# 버전 확인
print(pd.__version__) # 예: 2.2.0
print(np.__version__) # 예: 1.26.3
2.4 실습용 샘플 데이터 준비
이 튜토리얼 전체에서 사용할 샘플 데이터를 Python 딕셔너리로 직접 만들겠습니다. 온라인 쇼핑몰 데이터를 가정합니다.
import pandas as pd
import numpy as np
# ─── 고객 데이터 ───
customers = pd.DataFrame({
'customer_id': [1, 2, 3, 4, 5, 6, 7, 8],
'name': ['김민수', '이영희', '박철수', '정수진', '최동현', '한지은', '윤서준', '강예린'],
'email': ['minsu@mail.com', 'younghee@mail.com', 'cheolsu@mail.com',
'sujin@mail.com', 'donghyun@mail.com', 'jieun@mail.com',
'seojun@mail.com', 'yerin@mail.com'],
'phone': ['010-1234-5678', '010-2345-6789', '010-3456-7890',
'010-4567-8901', '010-5678-9012', '010-6789-0123',
'010-7890-1234', '010-8901-2345'],
'city': ['서울', '부산', '대전', '서울', '인천', '서울', '대구', '부산'],
'join_date': pd.to_datetime(['2023-01-15', '2023-03-22', '2023-05-10',
'2023-07-04', '2023-09-18', '2024-01-05',
'2024-02-14', '2024-04-20']),
'grade': ['GOLD', 'SILVER', 'BRONZE', 'GOLD', 'SILVER', 'BRONZE', 'GOLD', 'BRONZE']
})
# ─── 상품 데이터 ───
products = pd.DataFrame({
'product_id': range(1, 11),
'product_name': ['무선 키보드', '무선 마우스', '27인치 모니터', '프로그래밍 입문서',
'SQL 완전정복', '노트북 파우치', 'USB-C 허브', '기계식 키보드',
'데스크 매트', '웹캠 HD'],
'category': ['전자기기', '전자기기', '전자기기', '도서', '도서',
'액세서리', '액세서리', '전자기기', '액세서리', '전자기기'],
'price': [45000, 32000, 350000, 28000, 32000,
25000, 55000, 89000, 18000, 65000],
'stock': [120, 200, 45, 300, 250, 180, 90, 60, 400, 75]
})
# ─── 주문 데이터 ───
orders = pd.DataFrame({
'order_id': range(1, 11),
'customer_id': [1, 1, 2, 3, 4, 5, 6, 7, 2, 1],
'order_date': pd.to_datetime([
'2024-01-10', '2024-02-15', '2024-01-22', '2024-03-05',
'2024-03-18', '2024-04-01', '2024-04-12', '2024-04-20',
'2024-05-03', '2024-05-15']),
'total_amount': [77000, 350000, 60000, 28000, 144000,
55000, 89000, 415000, 32000, 83000],
'status': ['COMPLETED', 'COMPLETED', 'COMPLETED', 'COMPLETED',
'SHIPPED', 'SHIPPED', 'PENDING', 'PENDING', 'CANCELLED', 'COMPLETED']
})
# ─── 직원 데이터 ───
employees = pd.DataFrame({
'emp_id': range(1, 9),
'name': ['김대표', '이팀장', '박사원', '최대리', '정팀장', '한사원', '윤과장', '강사원'],
'department': ['경영', '개발', '개발', '개발', '마케팅', '마케팅', '영업', '영업'],
'position': ['CEO', '팀장', '사원', '대리', '팀장', '사원', '과장', '사원'],
'salary': [12000000, 7000000, 4000000, 5000000, 6500000, 3800000, 5500000, 3500000],
'hire_date': pd.to_datetime([
'2020-01-01', '2020-03-15', '2022-06-01', '2021-09-10',
'2020-07-20', '2023-01-10', '2021-02-28', '2023-08-15']),
'manager_id': [np.nan, 1, 2, 2, 1, 5, 1, 7]
})
print("✅ 샘플 데이터 생성 완료!")
print(f" customers: {customers.shape}")
print(f" products: {products.shape}")
print(f" orders: {orders.shape}")
print(f" employees: {employees.shape}")
Series - 1차원 데이터의 기본
3.1 Series란?
Series는 인덱스(label)가 붙은 1차원 배열입니다. 엑셀에서 하나의 열(Column)을 떼어낸 것과 같습니다. 모든 값은 동일한 데이터 타입을 가집니다.
3.2 Series 생성
# ① 리스트에서 생성
s1 = pd.Series([10, 20, 30, 40, 50])
print(s1)
# 0 10
# 1 20
# 2 30
# 3 40
# 4 50
# dtype: int64
# ② 인덱스를 직접 지정
s2 = pd.Series([45000, 32000, 89000],
index=['키보드', '마우스', '모니터'],
name='가격')
print(s2)
# 키보드 45000
# 마우스 32000
# 모니터 89000
# Name: 가격, dtype: int64
# ③ 딕셔너리에서 생성 (키 → 인덱스, 값 → 데이터)
scores = {'국어': 90, '영어': 85, '수학': 95, '과학': 88}
s3 = pd.Series(scores, name='점수')
print(s3)
# ④ DataFrame의 한 컬럼을 꺼내면 Series
city_series = customers['city']
print(type(city_series)) #
3.3 Series 기본 속성
s = pd.Series([45000, 32000, 89000, None, 55000],
index=['A', 'B', 'C', 'D', 'E'], name='가격')
print(s.values) # [45000. 32000. 89000. nan 55000.] ← NumPy 배열
print(s.index) # Index(['A', 'B', 'C', 'D', 'E'], dtype='object')
print(s.dtype) # float64 (NaN이 있으면 float로 변환)
print(s.name) # 가격
print(s.shape) # (5,)
print(len(s)) # 5
print(s.size) # 5
print(s.is_unique) # True (모든 값이 고유한가?)
print(s.hasnans) # True (결측치가 있는가?)
3.4 Series 인덱싱
s = pd.Series([10, 20, 30, 40, 50], index=['a', 'b', 'c', 'd', 'e'])
# 라벨로 접근
print(s['c']) # 30
print(s[['a', 'c', 'e']]) # 여러 개 선택
# 위치(정수)로 접근
print(s.iloc[0]) # 10
print(s.iloc[-1]) # 50
print(s.iloc[1:4]) # b=20, c=30, d=40
# 조건으로 필터링
print(s[s > 25]) # c=30, d=40, e=50
3.5 Series 연산
Series는 벡터 연산을 지원합니다. for 루프 없이 전체 데이터에 한 번에 연산을 적용할 수 있어 매우 빠릅니다. 이를 브로드캐스팅(Broadcasting)이라고 합니다.
prices = products['price']
# 산술 연산 (전체 값에 한 번에 적용)
print(prices * 1.1) # 모든 가격에 10% 인상
print(prices - 5000) # 모든 가격에서 5000원 할인
print(prices / 1000) # 원 → 천 단위
# 통계 메서드
print(prices.sum()) # 합계: 739000
print(prices.mean()) # 평균: 73900.0
print(prices.median()) # 중앙값: 50000.0
print(prices.std()) # 표준편차
print(prices.min()) # 최솟값: 18000
print(prices.max()) # 최댓값: 350000
print(prices.describe()) # 요약 통계 한 번에
# 고유값 관련
cities = customers['city']
print(cities.unique()) # ['서울' '부산' '대전' '인천' '대구']
print(cities.nunique()) # 5 (고유 값 수)
print(cities.value_counts()) # 각 값의 빈도수
DataFrame - 2차원 테이블의 핵심
4.1 DataFrame이란?
DataFrame은 행(Row)과 열(Column)로 이루어진 2차원 테이블입니다. 엑셀 스프레드시트나 SQL 테이블과 동일한 구조입니다. Pandas에서 가장 많이 사용하는 자료구조이며, 데이터 분석의 99%는 DataFrame을 다루는 작업입니다.
4.2 DataFrame 생성
# ① 딕셔너리에서 생성 (가장 흔한 방법)
df = pd.DataFrame({
'이름': ['김민수', '이영희', '박철수'],
'나이': [28, 34, 22],
'도시': ['서울', '부산', '대전']
})
print(df)
# 이름 나이 도시
# 0 김민수 28 서울
# 1 이영희 34 부산
# 2 박철수 22 대전
# ② 리스트의 리스트에서 생성
df2 = pd.DataFrame(
[['김민수', 28, '서울'],
['이영희', 34, '부산'],
['박철수', 22, '대전']],
columns=['이름', '나이', '도시']
)
# ③ 딕셔너리의 리스트에서 생성
data = [
{'이름': '김민수', '나이': 28, '도시': '서울'},
{'이름': '이영희', '나이': 34, '도시': '부산'},
{'이름': '박철수', '나이': 22, '도시': '대전'}
]
df3 = pd.DataFrame(data)
# ④ NumPy 배열에서 생성
arr = np.random.randint(60, 100, size=(5, 3))
df4 = pd.DataFrame(arr, columns=['국어', '영어', '수학'])
4.3 DataFrame 기본 속성
df = customers # 앞서 만든 고객 데이터 사용
print(df.shape) # (8, 7) — 8행 7열
print(df.columns) # 컬럼 이름 목록
print(df.index) # 인덱스 정보
print(df.dtypes) # 각 컬럼의 데이터 타입
print(df.values) # NumPy 배열로 변환
print(len(df)) # 행의 수: 8
print(df.size) # 전체 셀 수: 56 (8×7)
print(df.ndim) # 차원 수: 2
print(df.empty) # 비어있는가?: False
4.4 빠르게 데이터 훑어보기
# 처음 5행 (기본값), 처음 n행
print(df.head()) # 처음 5행
print(df.head(3)) # 처음 3행
# 마지막 5행 (기본값), 마지막 n행
print(df.tail()) # 마지막 5행
print(df.tail(2)) # 마지막 2행
# 랜덤으로 n행 샘플링
print(df.sample(3)) # 무작위 3행
# 전체 정보 요약 (컬럼명, 타입, 결측치 수 등)
df.info()
# 숫자형 컬럼의 통계 요약
print(df.describe())
# 모든 컬럼의 통계 요약 (문자열 포함)
print(df.describe(include='all'))
df.shape, df.head(), df.info(), df.describe() 네 가지를 실행하세요. 이것만으로 데이터의 전체적인 윤곽을 빠르게 파악할 수 있습니다.
데이터 불러오기 · 저장하기
5.1 CSV 파일 읽기 · 쓰기
CSV(Comma-Separated Values)는 가장 보편적인 데이터 파일 형식입니다. Pandas의 read_csv()는 매우 강력한 옵션을 제공합니다.
# ─── CSV 읽기 ───
df = pd.read_csv('data.csv') # 기본
df = pd.read_csv('data.csv', encoding='utf-8') # 인코딩 지정
df = pd.read_csv('data.csv', encoding='cp949') # 한글 Windows 파일
# 주요 옵션
df = pd.read_csv('data.csv',
sep=',', # 구분자 (기본: 쉼표)
header=0, # 헤더 행 번호 (기본: 첫 번째 행)
index_col='id', # 인덱스로 사용할 컬럼
usecols=['name', 'age', 'city'], # 특정 컬럼만 읽기
nrows=1000, # 처음 1000행만 읽기
skiprows=[1, 2], # 건너뛸 행
na_values=['N/A', '-', ''], # 결측값으로 처리할 문자열
dtype={'age': int, 'name': str}, # 데이터 타입 지정
parse_dates=['date_col'], # 날짜 컬럼 자동 파싱
)
# ─── CSV 저장 ───
df.to_csv('output.csv', index=False) # 인덱스 제외하고 저장
df.to_csv('output.csv', index=False, encoding='utf-8-sig') # 엑셀 한글 호환
UnicodeDecodeError가 발생하면, encoding='cp949' 또는 encoding='euc-kr'을 시도하세요. 저장할 때 encoding='utf-8-sig'를 사용하면 엑셀에서 한글이 깨지지 않습니다.
5.2 Excel 파일 읽기 · 쓰기
# 사전 설치 필요: pip install openpyxl
# ─── Excel 읽기 ───
df = pd.read_excel('data.xlsx') # 기본 (첫 번째 시트)
df = pd.read_excel('data.xlsx', sheet_name='매출') # 특정 시트
df = pd.read_excel('data.xlsx', sheet_name=1) # 두 번째 시트 (0부터)
# 모든 시트를 한 번에 읽기 (딕셔너리로 반환)
all_sheets = pd.read_excel('data.xlsx', sheet_name=None)
for name, sheet_df in all_sheets.items():
print(f"시트: {name}, 크기: {sheet_df.shape}")
# ─── Excel 저장 ───
df.to_excel('output.xlsx', index=False, sheet_name='결과')
# 여러 시트에 저장
with pd.ExcelWriter('report.xlsx') as writer:
customers.to_excel(writer, sheet_name='고객', index=False)
products.to_excel(writer, sheet_name='상품', index=False)
orders.to_excel(writer, sheet_name='주문', index=False)
5.3 기타 파일 형식
# ─── JSON ───
df = pd.read_json('data.json')
df.to_json('output.json', orient='records', force_ascii=False)
# ─── Parquet (대용량에 적합, 매우 빠름) ───
# pip install pyarrow
df = pd.read_parquet('data.parquet')
df.to_parquet('output.parquet', index=False)
# ─── 클립보드 (엑셀에서 복사한 데이터 바로 불러오기!) ───
df = pd.read_clipboard()
# ─── SQL 데이터베이스 ───
# pip install sqlalchemy pymysql
from sqlalchemy import create_engine
engine = create_engine('mysql+pymysql://user:pass@localhost/db_name')
df = pd.read_sql('SELECT * FROM customers', engine)
df.to_sql('table_name', engine, if_exists='replace', index=False)
# ─── URL에서 직접 읽기 ───
url = 'https://example.com/data.csv'
df = pd.read_csv(url)
| 파일 형식 | 읽기 함수 | 쓰기 메서드 | 특징 |
|---|---|---|---|
| CSV | pd.read_csv() | df.to_csv() | 가장 보편적, 텍스트 기반 |
| Excel | pd.read_excel() | df.to_excel() | 다중 시트, 서식 유지 |
| JSON | pd.read_json() | df.to_json() | 웹 API 데이터 |
| Parquet | pd.read_parquet() | df.to_parquet() | 대용량, 고속, 압축 |
| SQL | pd.read_sql() | df.to_sql() | 데이터베이스 직접 연동 |
| HTML 표 | pd.read_html() | df.to_html() | 웹페이지의 표 크롤링 |
데이터 탐색 (EDA)
6.1 EDA란?
EDA(Exploratory Data Analysis, 탐색적 데이터 분석)는 본격적인 분석에 앞서 데이터의 구조, 분포, 이상치, 결측치 등을 파악하는 과정입니다. 어떤 분석 프로젝트든 EDA가 첫 번째 단계입니다.
6.2 구조 파악
df = products
# 기본 구조 확인
print(df.shape) # (10, 5) → 10행 5열
print(df.columns.tolist()) # ['product_id', 'product_name', 'category', 'price', 'stock']
print(df.dtypes) # 각 컬럼의 데이터 타입
df.info() # 종합 정보 (타입, 결측치, 메모리 사용량)
# 메모리 사용량 확인
print(df.memory_usage(deep=True)) # 컬럼별 메모리 사용량 (바이트)
6.3 통계 요약
# 숫자형 컬럼 통계 요약
print(products.describe())
# 모든 컬럼 (문자열 포함) 요약
print(products.describe(include='all'))
# 개별 통계
print(products['price'].mean()) # 평균가격: 73900
print(products['price'].median()) # 중앙값: 50000
print(products['price'].std()) # 표준편차
print(products['price'].quantile([0.25, 0.5, 0.75])) # 사분위수
6.4 분포 확인
# 범주형 데이터의 분포
print(products['category'].value_counts())
# 전자기기 5
# 액세서리 3
# 도서 2
# 비율로 보기
print(products['category'].value_counts(normalize=True))
# 전자기기 0.5
# 액세서리 0.3
# 도서 0.2
# 고유 값 확인
print(customers['city'].unique()) # ['서울', '부산', '대전', '인천', '대구']
print(customers['city'].nunique()) # 5
# 상관관계 (숫자형 컬럼 간)
print(products[['price', 'stock']].corr())
6.5 결측치 파악
# 결측치 여부 확인 (True/False)
print(employees.isnull())
# 컬럼별 결측치 개수
print(employees.isnull().sum())
# manager_id 컬럼에 1개의 결측치 (CEO)
# 결측치 비율
print((employees.isnull().sum() / len(employees) * 100).round(1))
# 결측치가 있는 행만 필터링
print(employees[employees['manager_id'].isnull()])
인덱싱과 선택
7.1 컬럼 선택
DataFrame에서 원하는 컬럼(열)을 선택하는 것은 가장 기본적인 작업입니다.
df = customers
# 단일 컬럼 선택 → Series 반환
names = df['name']
print(type(names)) #
# 여러 컬럼 선택 → DataFrame 반환 (리스트로 감쌈)
subset = df[['name', 'city', 'grade']]
print(type(subset)) #
# 점(.) 표기법으로도 접근 가능 (컬럼명에 공백이나 특수문자가 없을 때)
print(df.name) # df['name']과 동일
df['name']은 Series를 반환하고, df[['name']]은 DataFrame을 반환합니다. 대괄호가 한 겹인지 두 겹인지에 따라 결과 타입이 달라지므로 주의하세요.
7.2 loc - 라벨 기반 인덱싱
loc은 인덱스 라벨(이름)과 컬럼 이름으로 데이터를 선택합니다. 슬라이싱할 때 끝 값이 포함됩니다.
df = customers
# 특정 행, 특정 컬럼
print(df.loc[0, 'name']) # '김민수' (0번 행의 name 컬럼)
# 여러 행, 여러 컬럼
print(df.loc[0:3, ['name', 'city', 'grade']])
# 주의! loc의 슬라이싱은 끝 값(3)을 포함합니다 → 0,1,2,3번 행
# 특정 행의 모든 컬럼
print(df.loc[0, :]) # 또는 df.loc[0]
# 모든 행의 특정 컬럼
print(df.loc[:, 'name']) # 또는 df['name']
# 조건과 결합
print(df.loc[df['city'] == '서울', ['name', 'grade']])
7.3 iloc - 위치 기반 인덱싱
iloc은 정수 위치(0부터 시작)로 데이터를 선택합니다. 파이썬의 일반 슬라이싱처럼 끝 값이 포함되지 않습니다.
df = customers
# 위치로 선택
print(df.iloc[0, 1]) # 0행 1열 → '김민수'
print(df.iloc[0:3, 0:3]) # 0~2행, 0~2열 (끝 값 미포함)
# 마지막 행
print(df.iloc[-1])
# 마지막 3행, 처음 2열
print(df.iloc[-3:, :2])
# 특정 행·열 위치 조합
print(df.iloc[[0, 3, 7], [1, 4]]) # 0,3,7행의 1,4열
7.4 loc vs iloc 비교
| 구분 | loc | iloc |
|---|---|---|
| 기반 | 라벨(이름) | 정수 위치 |
| 슬라이싱 끝 값 | 포함 | 미포함 |
| 문법 | df.loc[행라벨, 열이름] | df.iloc[행번호, 열번호] |
| 조건 필터링 | 가능 | 불가 |
| 사용 시기 | 컬럼명·인덱스명을 알 때 | 위치(순서)로 접근할 때 |
7.5 at / iat - 단일 값 접근 (빠른 버전)
# at : 라벨 기반 단일 값 (loc보다 빠름)
print(df.at[0, 'name']) # '김민수'
# iat : 위치 기반 단일 값 (iloc보다 빠름)
print(df.iat[0, 1]) # '김민수'
# 단일 값을 수정할 때도 사용
df.at[0, 'grade'] = 'VIP'
필터링과 조건 선택
8.1 불리언 인덱싱 (기본 필터링)
Pandas에서 조건에 맞는 데이터를 선택하는 가장 기본적인 방법은 불리언 마스크(Boolean Mask)를 사용하는 것입니다. 조건식의 결과가 True인 행만 선택됩니다.
df = products
# 가격이 50000 이상인 상품
expensive = df[df['price'] >= 50000]
print(expensive)
# 원리 이해: df['price'] >= 50000 → True/False로 이루어진 Series
mask = df['price'] >= 50000
print(mask)
# 0 False
# 1 False
# 2 True
# ...
# 이 True/False Series를 df[]에 넣으면 True인 행만 선택됨
# 카테고리가 '전자기기'인 상품
electronics = df[df['category'] == '전자기기']
# 재고가 100 미만인 상품
low_stock = df[df['stock'] < 100]
8.2 복합 조건 (AND, OR, NOT)
여러 조건을 결합할 때는 &(AND), |(OR), ~(NOT) 연산자를 사용합니다. 각 조건은 반드시 괄호로 감싸야 합니다.
df = products
# AND : 두 조건 모두 만족
# 전자기기이면서 가격 50000 이상
result = df[(df['category'] == '전자기기') & (df['price'] >= 50000)]
print(result)
# OR : 둘 중 하나 만족
# 전자기기이거나 가격 50000 이상
result = df[(df['category'] == '전자기기') | (df['price'] >= 50000)]
# NOT : 조건의 반대
# 전자기기가 아닌 상품
result = df[~(df['category'] == '전자기기')]
# 복합 조건 조합
# (전자기기 OR 도서) AND (가격 50000 이하)
result = df[
((df['category'] == '전자기기') | (df['category'] == '도서'))
& (df['price'] <= 50000)
]
8.3 isin() - 목록에서 필터링
# SQL의 IN과 동일
# 서울, 부산, 대구에 사는 고객
result = customers[customers['city'].isin(['서울', '부산', '대구'])]
print(result)
# NOT IN : ~(물결표)를 붙여 반전
result = customers[~customers['city'].isin(['서울', '부산'])]
8.4 between() - 범위 필터링
# SQL의 BETWEEN과 동일
# 가격이 30000 ~ 60000 사이인 상품
result = products[products['price'].between(30000, 60000)]
print(result)
8.5 문자열 조건 (str 접근자)
# SQL의 LIKE와 유사
# '무선'으로 시작하는 상품
result = products[products['product_name'].str.startswith('무선')]
# '키보드'로 끝나는 상품
result = products[products['product_name'].str.endswith('키보드')]
# '마우스'가 포함된 상품
result = products[products['product_name'].str.contains('마우스')]
# 정규표현식 사용
result = products[products['product_name'].str.contains('키보드|마우스')]
8.6 query() 메서드 - SQL 스타일 필터링
query()를 사용하면 SQL의 WHERE처럼 문자열로 조건을 작성할 수 있습니다. 복잡한 괄호 없이 깔끔하게 작성할 수 있어 편리합니다.
# query()를 사용한 필터링
result = products.query('price >= 50000')
result = products.query('category == "전자기기" and price >= 50000')
result = products.query('category in ["전자기기", "도서"]')
result = products.query('30000 <= price <= 60000')
# 변수를 사용할 때는 @를 붙임
max_price = 50000
result = products.query('price <= @max_price')
정렬과 순위
9.1 sort_values() - 값 기준 정렬
df = products
# 가격 오름차순 (기본값)
print(df.sort_values('price'))
# 가격 내림차순
print(df.sort_values('price', ascending=False))
# 여러 컬럼으로 정렬 (1차: 카테고리 오름차순, 2차: 가격 내림차순)
print(df.sort_values(['category', 'price'], ascending=[True, False]))
# NaN 위치 지정
# na_position='first' → NaN이 맨 위에
# na_position='last' → NaN이 맨 아래 (기본값)
9.2 sort_index() - 인덱스 기준 정렬
# 인덱스 기준 내림차순 정렬
print(df.sort_index(ascending=False))
# 컬럼 이름 기준 정렬 (열 순서 변경)
print(df.sort_index(axis=1)) # 컬럼 이름 알파벳순
9.3 nlargest() / nsmallest() - TOP N 추출
정렬 후 LIMIT을 거는 것보다 더 빠르고 간결합니다.
# 가격 TOP 5
print(products.nlargest(5, 'price'))
# 재고 BOTTOM 3 (가장 적은 재고)
print(products.nsmallest(3, 'stock'))
# 급여 TOP 3
print(employees.nlargest(3, 'salary')[['name', 'department', 'salary']])
9.4 rank() - 순위 매기기
# 급여 순위 (내림차순 = 급여가 높을수록 1등)
employees['salary_rank'] = employees['salary'].rank(ascending=False, method='min')
print(employees[['name', 'salary', 'salary_rank']].sort_values('salary_rank'))
# method 옵션:
# 'min' : 동일 값에 최소 순위 부여 (1,2,2,4)
# 'max' : 동일 값에 최대 순위 부여 (1,3,3,4)
# 'average' : 동일 값에 평균 순위 부여 (1,2.5,2.5,4) - 기본값
# 'first' : 먼저 나온 값에 높은 순위 (1,2,3,4)
# 'dense' : 빈 순위 없이 (1,2,2,3)
컬럼 추가 · 수정 · 삭제
10.1 새 컬럼 추가
df = products.copy() # 원본 보존을 위해 복사
# ① 단순 값 할당
df['discount_rate'] = 0.1 # 모든 행에 동일한 값
# ② 기존 컬럼으로 계산
df['discount_price'] = df['price'] * (1 - df['discount_rate'])
df['total_value'] = df['price'] * df['stock']
# ③ 조건부 할당 (np.where)
df['price_level'] = np.where(df['price'] >= 50000, '고가', '저가')
# ④ 여러 조건 (np.select)
conditions = [
df['price'] >= 100000,
df['price'] >= 50000,
df['price'] >= 20000,
]
choices = ['★★★ 고가', '★★ 중가', '★ 저가']
df['price_grade'] = np.select(conditions, choices, default='초저가')
print(df[['product_name', 'price', 'price_level', 'price_grade']])
10.2 assign() 메서드 - 함수형 컬럼 추가
assign()은 원본을 수정하지 않고 새 DataFrame을 반환합니다. 메서드 체이닝에 유용합니다.
# assign()으로 여러 컬럼 한 번에 추가
result = (products
.assign(
할인가 = lambda x: x['price'] * 0.9,
재고가치 = lambda x: x['price'] * x['stock'],
재고상태 = lambda x: np.where(x['stock'] >= 100, '충분', '부족')
)
)
print(result.head())
10.3 컬럼 수정
df = products.copy()
# 전체 가격 10% 인상
df['price'] = df['price'] * 1.1
# 특정 조건의 행만 수정 (loc 사용)
df.loc[df['category'] == '도서', 'price'] = df['price'] * 0.95
# 컬럼 이름 변경
df = df.rename(columns={'product_name': '상품명', 'price': '가격'})
# 모든 컬럼 이름 한 번에 변경
df.columns = ['ID', '상품명', '분류', '가격', '재고']
10.4 컬럼 삭제
df = products.copy()
# 단일 컬럼 삭제
df = df.drop(columns='stock')
# 또는
df = df.drop('stock', axis=1)
# 여러 컬럼 삭제
df = df.drop(columns=['stock', 'category'])
# 원본을 직접 수정 (inplace)
df.drop(columns='stock', inplace=True)
# del 키워드로도 삭제 가능 (원본 수정)
del df['category']
10.5 행 삭제
df = products.copy()
# 인덱스 번호로 행 삭제
df = df.drop(index=[0, 1]) # 0번, 1번 행 삭제
# 조건으로 행 삭제 (조건의 반대를 선택)
df = df[df['price'] >= 30000] # 30000 미만인 행 제거
# 인덱스 리셋 (삭제 후 인덱스가 불연속일 때)
df = df.reset_index(drop=True)
결측치(Missing Value) 처리
11.1 결측치란?
결측치(NaN, Not a Number)는 값이 없는 상태를 나타냅니다. 데이터를 수집하는 과정에서 미입력, 시스템 오류, 정보 미존재 등의 이유로 발생합니다. 결측치를 적절히 처리하지 않으면 분석 결과가 왜곡될 수 있습니다.
11.2 결측치 확인
# 결측치가 있는 샘플 데이터 생성
df = pd.DataFrame({
'이름': ['김민수', '이영희', '박철수', '정수진', '최동현'],
'나이': [28, np.nan, 22, 35, np.nan],
'도시': ['서울', '부산', np.nan, '서울', '인천'],
'급여': [4500000, 3800000, np.nan, 5200000, 4100000]
})
# 결측치 여부 확인
print(df.isnull()) # True/False 테이블
print(df.notnull()) # isnull()의 반대
# 컬럼별 결측치 개수
print(df.isnull().sum())
# 이름 0
# 나이 2
# 도시 1
# 급여 1
# 전체 결측치 개수
print(df.isnull().sum().sum()) # 4
# 결측치 비율
print((df.isnull().sum() / len(df) * 100).round(1))
# 결측치가 있는 행만 보기
print(df[df.isnull().any(axis=1)])
11.3 결측치 삭제 (dropna)
# 결측치가 하나라도 있는 행 삭제 (기본값)
clean = df.dropna()
# 모든 값이 NaN인 행만 삭제
clean = df.dropna(how='all')
# 특정 컬럼에 결측치가 있는 행만 삭제
clean = df.dropna(subset=['나이', '급여'])
# 열(컬럼) 기준 삭제: 결측치가 있는 컬럼 제거
clean = df.dropna(axis=1)
# 최소 n개의 비결측 값이 있는 행만 유지
clean = df.dropna(thresh=3) # 최소 3개 이상 값이 있는 행만
11.4 결측치 채우기 (fillna)
# 특정 값으로 채우기
df_filled = df.fillna(0) # 모든 결측치를 0으로
df_filled = df.fillna('미입력') # 모든 결측치를 '미입력'으로
# 컬럼별 다른 값으로 채우기
df_filled = df.fillna({
'나이': df['나이'].mean(), # 나이는 평균값
'도시': '미확인', # 도시는 '미확인'
'급여': df['급여'].median() # 급여는 중앙값
})
# 앞의 값으로 채우기 (시계열 데이터에 유용)
df_filled = df.fillna(method='ffill') # forward fill
# 또는 pandas 2.x:
df_filled = df.ffill()
# 뒤의 값으로 채우기
df_filled = df.fillna(method='bfill') # backward fill
# 또는 pandas 2.x:
df_filled = df.bfill()
# 보간법 (시계열에서 유용)
df_filled = df.interpolate() # 선형 보간
데이터 타입 변환
12.1 데이터 타입 확인
print(products.dtypes)
# product_id int64
# product_name object ← 문자열은 object로 표시
# category object
# price int64
# stock int64
12.2 astype() - 타입 변환
df = products.copy()
# 정수 → 실수
df['price'] = df['price'].astype(float)
# 실수 → 정수 (소수점 버림)
df['price'] = df['price'].astype(int)
# 숫자 → 문자열
df['product_id'] = df['product_id'].astype(str)
# 문자열 → 카테고리형 (메모리 절약, 성능 향상)
df['category'] = df['category'].astype('category')
# 여러 컬럼 한 번에 변환
df = df.astype({
'price': float,
'stock': float
})
12.3 수치 변환 (to_numeric)
문자열이 섞인 숫자 컬럼을 변환할 때 astype()은 에러가 나지만, pd.to_numeric()은 에러를 처리할 수 있습니다.
# 문자열이 섞인 데이터
s = pd.Series(['100', '200', 'abc', '400', None])
# astype(int) → 에러 발생!
# s.astype(int) # ValueError!
# to_numeric으로 안전하게 변환
result = pd.to_numeric(s, errors='coerce') # 변환 불가 → NaN
print(result)
# 0 100.0
# 1 200.0
# 2 NaN ← 'abc'가 NaN으로
# 3 400.0
# 4 NaN
# errors 옵션:
# 'raise' : 에러 발생 (기본값)
# 'coerce' : 변환 불가 시 NaN
# 'ignore' : 변환 불가 시 원본 유지
12.4 날짜 변환 (to_datetime)
# 문자열 → 날짜
dates = pd.Series(['2024-01-15', '2024/02/20', '20240315'])
result = pd.to_datetime(dates)
print(result)
# 한국식 날짜 형식
korean_dates = pd.Series(['2024년 01월 15일', '2024년 02월 20일'])
result = pd.to_datetime(korean_dates, format='%Y년 %m월 %d일')
# 에러 처리
bad_dates = pd.Series(['2024-01-15', 'not a date', '2024-03-20'])
result = pd.to_datetime(bad_dates, errors='coerce') # 잘못된 값 → NaT
12.5 카테고리형 (category)
반복되는 문자열이 많은 컬럼은 category 타입으로 변환하면 메모리를 크게 절약하고 연산 속도도 빨라집니다.
df = customers.copy()
# 변환 전 메모리
print(df['grade'].memory_usage(deep=True)) # 예: 596 bytes
# 카테고리형으로 변환
df['grade'] = df['grade'].astype('category')
print(df['grade'].memory_usage(deep=True)) # 예: 362 bytes
# 순서가 있는 카테고리 (Ordered)
grade_order = pd.CategoricalDtype(
categories=['BRONZE', 'SILVER', 'GOLD'],
ordered=True
)
df['grade'] = df['grade'].astype(grade_order)
# 이제 비교 연산이 가능!
print(df[df['grade'] > 'SILVER']) # GOLD만 선택
문자열 처리 (str 접근자)
13.1 str 접근자란?
Pandas의 Series에 .str을 붙이면 문자열 전용 메서드를 사용할 수 있습니다. 파이썬의 문자열 메서드와 이름이 거의 동일하며, Series 전체에 벡터화(vectorized)되어 적용됩니다.
names = customers['name']
# 대소문자 변환
emails = customers['email']
print(emails.str.upper()) # 전체 대문자
print(emails.str.lower()) # 전체 소문자
print(emails.str.title()) # 단어 첫 글자만 대문자
# 문자열 길이
print(names.str.len()) # 각 이름의 글자 수
# 포함 여부 (불리언 반환)
print(emails.str.contains('mail')) # True/False Series
print(names.str.startswith('김'))
print(names.str.endswith('수'))
# 부분 문자열 추출
print(names.str[0]) # 각 이름의 첫 글자 (성씨)
print(names.str[:2]) # 각 이름의 처음 2글자
13.2 문자열 변환
# 공백 제거
s = pd.Series([' hello ', ' world ', ' pandas '])
print(s.str.strip()) # 양쪽 공백 제거
print(s.str.lstrip()) # 왼쪽 공백 제거
print(s.str.rstrip()) # 오른쪽 공백 제거
# 치환
phones = customers['phone']
print(phones.str.replace('-', '')) # 하이픈 제거: 01012345678
# 정규표현식으로 치환
print(phones.str.replace(r'\d{4}$', '****', regex=True))
# 010-1234-**** (뒤 4자리 마스킹)
# 패딩
s = pd.Series(['1', '23', '456'])
print(s.str.zfill(5)) # 00001, 00023, 00456
print(s.str.pad(5, fillchar='0')) # 위와 동일
13.3 분할(split)과 결합(cat)
# 분할 (split)
emails = customers['email']
# '@' 기준 분할 → 각 행이 리스트
print(emails.str.split('@'))
# expand=True → 여러 컬럼으로 분리
email_parts = emails.str.split('@', expand=True)
email_parts.columns = ['username', 'domain']
print(email_parts)
# 전화번호에서 지역번호 추출
phones = customers['phone']
print(phones.str.split('-').str[0]) # '010' 부분만
# 결합 (cat)
first = pd.Series(['김', '이', '박'])
last = pd.Series(['민수', '영희', '철수'])
print(first.str.cat(last, sep='')) # 김민수, 이영희, 박철수
13.4 실전 활용 예제
# ① 이메일 도메인 추출
customers['domain'] = customers['email'].str.split('@').str[1]
# ② 전화번호 마스킹
customers['masked_phone'] = (
customers['phone'].str[:3] + '-****-' + customers['phone'].str[-4:]
)
# ③ 이름에서 성씨 추출 → 성씨별 통계
customers['family_name'] = customers['name'].str[0]
print(customers['family_name'].value_counts())
# ④ 상품명 검색 (대소문자 무시)
result = products[products['product_name'].str.lower().str.contains('usb')]
# ⑤ 주문번호 형식 만들기: ORD-00001
orders['order_no'] = 'ORD-' + orders['order_id'].astype(str).str.zfill(5)
날짜와 시간 처리
14.1 datetime 타입
Pandas에서 날짜/시간 데이터는 datetime64 타입으로 다룹니다. 이 타입이어야 날짜 연산(뺄셈, 추출 등)이 가능합니다.
# 문자열 → datetime 변환
dates = pd.to_datetime(['2024-01-15', '2024-02-20', '2024-03-10'])
print(dates.dtype) # datetime64[ns]
# DataFrame의 컬럼 변환
df = orders.copy()
df['order_date'] = pd.to_datetime(df['order_date'])
14.2 날짜 요소 추출 (dt 접근자)
.dt 접근자를 사용하면 datetime Series에서 연, 월, 일, 요일 등을 추출할 수 있습니다.
dates = orders['order_date']
print(dates.dt.year) # 연도
print(dates.dt.month) # 월 (1~12)
print(dates.dt.day) # 일 (1~31)
print(dates.dt.hour) # 시
print(dates.dt.dayofweek) # 요일 (0=월 ~ 6=일)
print(dates.dt.day_name()) # 요일 이름 (Monday, ...)
print(dates.dt.quarter) # 분기 (1~4)
print(dates.dt.week) # 주차
print(dates.dt.date) # 날짜 부분만 (시간 제거)
# 문자열 포맷으로 변환
print(dates.dt.strftime('%Y년 %m월 %d일'))
# 2024년 01월 10일, 2024년 02월 15일, ...
14.3 날짜 연산
# 날짜 차이 (TimeDelta)
today = pd.Timestamp.now()
customers['days_since_join'] = (today - customers['join_date']).dt.days
print(customers[['name', 'join_date', 'days_since_join']])
# 날짜 더하기/빼기
orders['delivery_date'] = orders['order_date'] + pd.Timedelta(days=3)
orders['one_month_later'] = orders['order_date'] + pd.DateOffset(months=1)
# 두 날짜 사이의 차이
first_order = orders['order_date'].min()
last_order = orders['order_date'].max()
print(f"기간: {(last_order - first_order).days}일")
14.4 날짜 기반 분석 예제
# ① 월별 주문 건수
monthly = orders.groupby(orders['order_date'].dt.to_period('M')).size()
print(monthly)
# ② 요일별 주문 분포
orders['weekday'] = orders['order_date'].dt.day_name()
print(orders['weekday'].value_counts())
# ③ 분기별 매출
orders['quarter'] = orders['order_date'].dt.to_period('Q')
quarterly_sales = orders.groupby('quarter')['total_amount'].sum()
print(quarterly_sales)
# ④ 최근 90일 내 주문 필터링
cutoff = pd.Timestamp.now() - pd.Timedelta(days=90)
recent = orders[orders['order_date'] >= cutoff]
# ⑤ 가입 후 N년차 계산
customers['years_since'] = (
(pd.Timestamp.now() - customers['join_date']).dt.days / 365.25
).round(1)
집계와 GroupBy
15.1 기본 집계 함수
prices = products['price']
print(prices.sum()) # 합계: 739000
print(prices.mean()) # 평균: 73900.0
print(prices.median()) # 중앙값: 50000.0
print(prices.std()) # 표준편차
print(prices.var()) # 분산
print(prices.min()) # 최솟값: 18000
print(prices.max()) # 최댓값: 350000
print(prices.count()) # 비결측 개수: 10
print(prices.nunique()) # 고유 값 수: 9
print(prices.idxmin()) # 최솟값의 인덱스
print(prices.idxmax()) # 최댓값의 인덱스
15.2 GroupBy - 그룹별 집계
groupby()는 SQL의 GROUP BY와 동일한 개념입니다. "Split → Apply → Combine" 패턴으로 작동합니다. 데이터를 그룹으로 나누고(Split), 각 그룹에 함수를 적용하고(Apply), 결과를 합칩니다(Combine).
# 카테고리별 상품 수
print(products.groupby('category').size())
# 또는
print(products.groupby('category')['product_id'].count())
# 카테고리별 가격 통계
print(products.groupby('category')['price'].mean())
print(products.groupby('category')['price'].agg(['mean', 'min', 'max', 'sum']))
# 부서별 직원 수, 평균 급여
dept_stats = employees.groupby('department').agg(
직원수=('emp_id', 'count'),
평균급여=('salary', 'mean'),
최고급여=('salary', 'max'),
최저급여=('salary', 'min')
).round(0)
print(dept_stats)
15.3 여러 컬럼으로 GroupBy
# 도시별, 등급별 고객 수
result = customers.groupby(['city', 'grade']).size().reset_index(name='count')
print(result)
# 월별, 상태별 매출
orders['month'] = orders['order_date'].dt.to_period('M')
monthly_status = orders.groupby(['month', 'status'])['total_amount'].sum()
print(monthly_status)
15.4 agg() - 다중 집계
agg()를 사용하면 컬럼마다 다른 집계 함수를 적용하거나, 하나의 컬럼에 여러 함수를 동시에 적용할 수 있습니다.
# 컬럼마다 다른 집계 함수
result = products.groupby('category').agg({
'product_id': 'count', # 상품 수
'price': ['mean', 'sum'], # 평균가, 합계
'stock': 'sum' # 총 재고
})
print(result)
# Named Aggregation (더 깔끔한 방식, pandas 0.25+)
result = products.groupby('category').agg(
상품수=('product_id', 'count'),
평균가격=('price', 'mean'),
가격합계=('price', 'sum'),
총재고=('stock', 'sum')
).round(0)
print(result)
# 커스텀 함수 적용
result = products.groupby('category').agg(
가격범위=('price', lambda x: x.max() - x.min()),
비싼상품=('product_name', lambda x: x.iloc[x.values.argsort()[-1]])
)
15.5 GroupBy 후 필터링
SQL의 HAVING에 해당하는 기능입니다.
# 고객이 2명 이상인 도시만
city_counts = customers.groupby('city').size()
print(city_counts[city_counts >= 2])
# filter() 사용: 그룹 전체를 유지하거나 제거
# 고객이 2명 이상인 도시의 고객만 남기기
result = customers.groupby('city').filter(lambda x: len(x) >= 2)
print(result)
데이터 결합 (Merge / Join / Concat)
16.1 merge() - SQL JOIN과 동일
pd.merge()는 SQL의 JOIN과 완전히 동일한 개념입니다. 공통 컬럼(키)을 기준으로 두 DataFrame을 결합합니다.
# ─── INNER JOIN (기본값): 양쪽 모두 일치하는 행만 ───
result = pd.merge(orders, customers, on='customer_id')
# 또는
result = orders.merge(customers, on='customer_id')
print(result[['order_id', 'name', 'city', 'total_amount', 'status']].head())
# ─── LEFT JOIN : 왼쪽 전체 + 오른쪽 일치 ───
result = pd.merge(customers, orders, on='customer_id', how='left')
# 주문 없는 고객도 포함 (NaN)
# ─── RIGHT JOIN ───
result = pd.merge(customers, orders, on='customer_id', how='right')
# ─── OUTER JOIN (FULL OUTER) ───
result = pd.merge(customers, orders, on='customer_id', how='outer')
16.2 merge() 상세 옵션
# 컬럼 이름이 다를 때
df_a = pd.DataFrame({'id': [1,2,3], 'value_a': [10,20,30]})
df_b = pd.DataFrame({'b_id': [1,2,4], 'value_b': [100,200,400]})
result = pd.merge(df_a, df_b, left_on='id', right_on='b_id', how='inner')
# 여러 키로 JOIN
result = pd.merge(orders, customers, on='customer_id').merge(
products, left_on='product_id', right_on='product_id' # 예시
)
# 동일 컬럼명 충돌 시 접미사 지정
result = pd.merge(df_a, df_b, left_on='id', right_on='b_id',
suffixes=('_left', '_right'))
# 인덱스를 키로 사용
result = pd.merge(df_a, df_b, left_index=True, right_index=True)
| how 옵션 | SQL 대응 | 설명 |
|---|---|---|
| inner | INNER JOIN | 양쪽 모두 일치하는 행만 (기본값) |
| left | LEFT JOIN | 왼쪽 전체 + 오른쪽 일치 |
| right | RIGHT JOIN | 오른쪽 전체 + 왼쪽 일치 |
| outer | FULL OUTER JOIN | 양쪽 전체 (합집합) |
| cross | CROSS JOIN | 모든 행의 조합 |
16.3 concat() - 단순 이어붙이기
concat()은 SQL의 UNION과 유사합니다. 같은 구조의 DataFrame을 위아래로 또는 좌우로 이어붙입니다.
# ─── 위아래로 이어붙이기 (행 방향, axis=0) ───
df1 = pd.DataFrame({'A': [1,2], 'B': [3,4]})
df2 = pd.DataFrame({'A': [5,6], 'B': [7,8]})
result = pd.concat([df1, df2], ignore_index=True)
# A B
# 0 1 3
# 1 2 4
# 2 5 7
# 3 6 8
# ─── 좌우로 이어붙이기 (열 방향, axis=1) ───
df3 = pd.DataFrame({'C': [9,10], 'D': [11,12]})
result = pd.concat([df1, df3], axis=1)
# A B C D
# 0 1 3 9 11
# 1 2 4 10 12
# 월별 데이터를 합치는 실전 패턴
# jan = pd.read_csv('2024_01.csv')
# feb = pd.read_csv('2024_02.csv')
# mar = pd.read_csv('2024_03.csv')
# all_data = pd.concat([jan, feb, mar], ignore_index=True)
16.4 실전 결합 예제
# 주문한 적 없는 고객 찾기 (LEFT JOIN + NaN 필터)
merged = pd.merge(customers, orders, on='customer_id', how='left')
no_orders = merged[merged['order_id'].isna()][['name', 'email', 'join_date']]
print("주문한 적 없는 고객:")
print(no_orders)
# 주문 상세 정보: 고객명 + 상품명 + 금액
# (여러 테이블을 체이닝으로 연결)
detail = (orders
.merge(customers[['customer_id', 'name', 'city']], on='customer_id')
.query('status != "CANCELLED"')
.sort_values('order_date', ascending=False)
)
print(detail[['order_id', 'name', 'city', 'total_amount', 'status']])
피벗 테이블과 데이터 재구조화
17.1 pivot_table() - 피벗 테이블
피벗 테이블은 행(index)과 열(columns)을 기준으로 데이터를 요약하는 크로스탭 형태의 집계표입니다. 엑셀의 피벗 테이블과 완전히 동일한 개념입니다.
# 주문 상태별 · 고객별 매출 피벗
merged = orders.merge(customers[['customer_id', 'name']], on='customer_id')
pivot = merged.pivot_table(
values='total_amount', # 집계할 값
index='name', # 행 기준
columns='status', # 열 기준
aggfunc='sum', # 집계 함수 (기본: mean)
fill_value=0 # NaN을 0으로 채움
)
print(pivot)
# margins=True : 합계 행/열 추가
pivot = merged.pivot_table(
values='total_amount',
index='name',
columns='status',
aggfunc='sum',
fill_value=0,
margins=True, # 합계 추가
margins_name='합계'
)
print(pivot)
17.2 crosstab() - 교차표
# 도시별 등급 분포 (빈도 교차표)
cross = pd.crosstab(customers['city'], customers['grade'], margins=True)
print(cross)
# 비율로 표시
cross_pct = pd.crosstab(customers['city'], customers['grade'], normalize='index')
print((cross_pct * 100).round(1))
17.3 melt() - 넓은 형태 → 긴 형태
melt()는 피벗의 반대 작업입니다. 여러 컬럼을 하나의 컬럼으로 녹입니다(unpivot).
# 넓은 형태 (Wide format)
wide = pd.DataFrame({
'학생': ['김민수', '이영희'],
'국어': [90, 85],
'영어': [80, 92],
'수학': [95, 88]
})
print(wide)
# 학생 국어 영어 수학
# 0 김민수 90 80 95
# 1 이영희 85 92 88
# 긴 형태로 변환 (Long format)
long = pd.melt(wide, id_vars='학생', var_name='과목', value_name='점수')
print(long)
# 학생 과목 점수
# 0 김민수 국어 90
# 1 이영희 국어 85
# 2 김민수 영어 80
# 3 이영희 영어 92
# 4 김민수 수학 95
# 5 이영희 수학 88
17.4 stack() / unstack()
# stack() : 컬럼 → 행 (넓은→긴)
# unstack() : 행 → 컬럼 (긴→넓은)
# GroupBy 결과를 unstack으로 피벗
result = orders.groupby([orders['order_date'].dt.month, 'status'])['total_amount'].sum()
print(result.unstack(fill_value=0))
Apply와 고급 변환
18.1 apply() - 함수 적용
apply()는 Series나 DataFrame의 각 요소/행/열에 사용자 정의 함수를 적용합니다. 내장 메서드로 처리하기 어려운 복잡한 변환에 유용합니다.
# ─── Series에 apply ───
# 가격대 분류 함수
def classify_price(price):
if price >= 100000:
return '고가'
elif price >= 50000:
return '중가'
else:
return '저가'
products['price_level'] = products['price'].apply(classify_price)
print(products[['product_name', 'price', 'price_level']])
# 람다 함수로 간단하게
products['tax'] = products['price'].apply(lambda x: x * 0.1)
# ─── DataFrame에 apply (행 단위) ───
# axis=1이면 각 행에 함수 적용
def order_info(row):
return f"{row['order_id']}번 주문 - {row['total_amount']:,}원 ({row['status']})"
orders['summary'] = orders.apply(order_info, axis=1)
print(orders['summary'])
18.2 map() - 값 매핑
map()은 Series의 각 값을 다른 값으로 변환합니다. 딕셔너리 매핑에 특히 유용합니다.
# 딕셔너리 매핑 (SQL의 CASE WHEN과 유사)
status_map = {
'COMPLETED': '완료',
'SHIPPED': '배송중',
'PENDING': '대기',
'CANCELLED': '취소'
}
orders['status_kr'] = orders['status'].map(status_map)
print(orders[['order_id', 'status', 'status_kr']])
# 등급별 할인율 매핑
grade_discount = {'GOLD': 0.15, 'SILVER': 0.10, 'BRONZE': 0.05}
customers['discount'] = customers['grade'].map(grade_discount)
18.3 replace() - 값 치환
# 단일 값 치환
df = orders.copy()
df['status'] = df['status'].replace('CANCELLED', '취소')
# 여러 값 치환 (딕셔너리)
df['status'] = df['status'].replace({
'COMPLETED': '완료',
'SHIPPED': '배송중',
'PENDING': '대기'
})
# 정규표현식 치환
df['phone'] = df['phone'].replace(r'\d{4}$', '****', regex=True)
18.4 중복 데이터 처리
# 중복 확인
print(orders.duplicated().sum()) # 전체 중복 행 수
print(orders.duplicated(subset=['customer_id']).sum()) # 특정 컬럼 기준
# 중복 행 보기
print(orders[orders.duplicated(subset=['customer_id'], keep=False)])
# 중복 제거
clean = orders.drop_duplicates() # 전체 컬럼 기준
clean = orders.drop_duplicates(subset=['customer_id']) # 특정 컬럼 기준
clean = orders.drop_duplicates(subset=['customer_id'], keep='last') # 마지막 것 유지
18.5 메서드 체이닝 (실무 스타일)
실무에서는 여러 변환을 하나의 흐름으로 연결하여 작성합니다. 이를 메서드 체이닝(Method Chaining)이라고 합니다.
# 메서드 체이닝으로 데이터 가공 파이프라인 만들기
result = (orders
.merge(customers[['customer_id', 'name', 'grade']], on='customer_id')
.query('status != "CANCELLED"')
.assign(
month=lambda x: x['order_date'].dt.to_period('M'),
status_kr=lambda x: x['status'].map({
'COMPLETED': '완료', 'SHIPPED': '배송중', 'PENDING': '대기'
})
)
.groupby(['name', 'grade'])
.agg(
주문횟수=('order_id', 'count'),
총매출=('total_amount', 'sum')
)
.sort_values('총매출', ascending=False)
.reset_index()
)
print(result)
()로 감싸면 여러 줄에 걸쳐 작성할 수 있습니다.
실전 프로젝트 - 쇼핑몰 데이터 분석
지금까지 배운 모든 내용을 종합하여, 실무에서 자주 사용하는 분석을 수행해 봅니다.
19.1 매출 종합 대시보드
# 취소 제외 주문만 필터링
valid_orders = orders[orders['status'] != 'CANCELLED']
dashboard = pd.Series({
'총 주문 수': valid_orders['order_id'].nunique(),
'구매 고객 수': valid_orders['customer_id'].nunique(),
'총 매출': f"{valid_orders['total_amount'].sum():,.0f}원",
'평균 주문액': f"{valid_orders['total_amount'].mean():,.0f}원",
'최대 주문액': f"{valid_orders['total_amount'].max():,.0f}원",
'최소 주문액': f"{valid_orders['total_amount'].min():,.0f}원",
})
print("═" * 40)
print(" 📊 매출 종합 대시보드")
print("═" * 40)
print(dashboard.to_string())
19.2 월별 매출 추이
monthly_sales = (valid_orders
.assign(month=lambda x: x['order_date'].dt.to_period('M'))
.groupby('month')
.agg(
주문건수=('order_id', 'count'),
매출=('total_amount', 'sum'),
평균주문액=('total_amount', 'mean'),
구매고객수=('customer_id', 'nunique')
)
.round(0)
)
print(monthly_sales)
19.3 VIP 고객 분석
# 고객별 구매 분석
customer_analysis = (valid_orders
.merge(customers[['customer_id', 'name', 'grade', 'city']], on='customer_id')
.groupby(['name', 'grade', 'city'])
.agg(
주문횟수=('order_id', 'count'),
총구매액=('total_amount', 'sum'),
평균구매액=('total_amount', 'mean'),
마지막주문=('order_date', 'max')
)
.round(0)
.sort_values('총구매액', ascending=False)
.reset_index()
)
# 활동 상태 판단
today = pd.Timestamp.now()
customer_analysis['경과일'] = (today - customer_analysis['마지막주문']).dt.days
customer_analysis['활동상태'] = np.select(
[
customer_analysis['경과일'] < 30,
customer_analysis['경과일'] < 90,
customer_analysis['경과일'] < 180,
],
['활성', '준활성', '휴면위험'],
default='휴면'
)
print(customer_analysis)
19.4 카테고리별 매출 비중
# 카테고리별 통계
cat_stats = products.groupby('category').agg(
상품수=('product_id', 'count'),
평균가격=('price', 'mean'),
총재고=('stock', 'sum'),
재고가치=('price', lambda x: (x * products.loc[x.index, 'stock']).sum())
).round(0)
cat_stats['재고비중(%)'] = (cat_stats['재고가치'] / cat_stats['재고가치'].sum() * 100).round(1)
print(cat_stats)
19.5 재구매율 분석
# 고객별 주문 횟수
order_counts = (valid_orders
.groupby('customer_id')
.size()
.reset_index(name='order_count')
)
total_buyers = order_counts['customer_id'].nunique()
repeat_buyers = (order_counts['order_count'] >= 2).sum()
repeat_rate = repeat_buyers / total_buyers * 100
print(f"전체 구매 고객: {total_buyers}명")
print(f"재구매 고객: {repeat_buyers}명")
print(f"재구매율: {repeat_rate:.1f}%")
19.6 재고 부족 알림 리포트
stock_report = products.copy()
stock_report['재고상태'] = np.select(
[
stock_report['stock'] == 0,
stock_report['stock'] < 50,
stock_report['stock'] < 100,
],
['🔴 품절', '🟡 부족', '🟠 보통'],
default='🟢 충분'
)
stock_report = stock_report.sort_values('stock')
print(stock_report[['product_name', 'category', 'stock', '재고상태']])
19.7 부서별 인건비 보고서
dept_report = (employees
.groupby('department')
.agg(
인원=('emp_id', 'count'),
급여합계=('salary', 'sum'),
평균급여=('salary', 'mean'),
최고급여=('salary', 'max'),
최저급여=('salary', 'min')
)
.round(0)
)
dept_report['인건비비중(%)'] = (
dept_report['급여합계'] / dept_report['급여합계'].sum() * 100
).round(1)
# 보기 좋게 포맷팅
for col in ['급여합계', '평균급여', '최고급여', '최저급여']:
dept_report[col] = dept_report[col].apply(lambda x: f"{x:,.0f}")
print(dept_report.sort_values('인건비비중(%)', ascending=False))
19.8 도시별 · 등급별 매출 피벗
# 도시별·등급별 매출 피벗 테이블
city_grade_pivot = (valid_orders
.merge(customers[['customer_id', 'city', 'grade']], on='customer_id')
.pivot_table(
values='total_amount',
index='city',
columns='grade',
aggfunc='sum',
fill_value=0,
margins=True,
margins_name='합계'
)
)
print(city_grade_pivot)
부록 - 치트시트 & 학습 로드맵
20.1 Pandas 핵심 문법 치트시트
# =============================================
# Pandas 치트시트 - 한눈에 보는 핵심 문법
# =============================================
import pandas as pd
import numpy as np
# ── 1. 생성 ──
pd.Series([1, 2, 3])
pd.DataFrame({'col1': [1,2], 'col2': [3,4]})
# ── 2. 파일 입출력 ──
pd.read_csv('file.csv') # / df.to_csv('file.csv', index=False)
pd.read_excel('file.xlsx') # / df.to_excel('file.xlsx', index=False)
pd.read_json('file.json') # / df.to_json('file.json')
pd.read_parquet('file.parquet') # / df.to_parquet('file.parquet')
# ── 3. 탐색 ──
df.shape | df.dtypes | df.columns | df.index
df.head(n) | df.tail(n) | df.sample(n)
df.info() | df.describe() | df.describe(include='all')
df.value_counts() | df.nunique() | df.unique()
df.isnull().sum() | df.duplicated().sum()
# ── 4. 선택 ──
df['col'] # 단일 컬럼 → Series
df[['col1', 'col2']] # 여러 컬럼 → DataFrame
df.loc[행라벨, 열이름] # 라벨 기반
df.iloc[행번호, 열번호] # 위치 기반
# ── 5. 필터링 ──
df[df['col'] > 값] # 비교
df[(조건1) & (조건2)] # AND
df[(조건1) | (조건2)] # OR
df[~조건] # NOT
df[df['col'].isin([값1, 값2])] # IN
df[df['col'].between(a, b)] # BETWEEN
df[df['col'].str.contains('패턴')] # LIKE
df.query('col > 값 and col2 == "문자열"') # SQL 스타일
# ── 6. 정렬 ──
df.sort_values('col', ascending=False)
df.sort_values(['col1','col2'], ascending=[True, False])
df.nlargest(n, 'col') | df.nsmallest(n, 'col')
# ── 7. 컬럼 관리 ──
df['new'] = 값 또는 계산식 # 추가
df = df.rename(columns={'old': 'new'}) # 이름 변경
df = df.drop(columns=['col']) # 삭제
df = df.assign(new_col=lambda x: ...) # 함수형 추가
# ── 8. 결측치 ──
df.isnull().sum() # 결측치 확인
df.dropna() # 결측치 행 삭제
df.fillna(값) # 결측치 채우기
df.fillna({'col1': 0, 'col2': '없음'}) # 컬럼별
# ── 9. 타입 변환 ──
df['col'].astype(int | float | str | 'category')
pd.to_numeric(s, errors='coerce')
pd.to_datetime(s, format='...')
# ── 10. 문자열 (str 접근자) ──
s.str.upper() | s.str.lower() | s.str.strip()
s.str.contains('패턴') | s.str.startswith('...')
s.str.replace('old', 'new') | s.str.split('구분자')
s.str.len() | s.str[0] | s.str[:3]
# ── 11. 날짜 (dt 접근자) ──
s.dt.year | s.dt.month | s.dt.day | s.dt.dayofweek
s.dt.strftime('%Y-%m-%d') | s.dt.to_period('M')
pd.Timedelta(days=7) | pd.DateOffset(months=1)
# ── 12. 집계 & GroupBy ──
df['col'].sum() | .mean() | .median() | .std()
df['col'].min() | .max() | .count() | .nunique()
df.groupby('col')['val'].mean()
df.groupby('col').agg(이름=('val', 'func'))
# ── 13. 결합 ──
pd.merge(df1, df2, on='key', how='inner|left|right|outer')
pd.concat([df1, df2], ignore_index=True)
# ── 14. 피벗 ──
df.pivot_table(values='val', index='row', columns='col', aggfunc='sum')
pd.crosstab(df['col1'], df['col2'])
pd.melt(df, id_vars='id', var_name='변수', value_name='값')
# ── 15. 변환 ──
df['col'].apply(함수) # Series에 함수 적용
df.apply(함수, axis=1) # 각 행에 함수 적용
df['col'].map(딕셔너리) # 값 매핑
df['col'].replace(old, new) # 값 치환
df.drop_duplicates() # 중복 제거
20.2 자주 하는 실수 & 해결법
| 실수 | 증상 | 해결법 |
|---|---|---|
| df['col'] 대신 df[['col']] | Series vs DataFrame 타입 혼동 | 단일=Series, 이중=DataFrame 기억하기 |
| 조건에 and/or 사용 | ValueError 에러 | & / | 사용 + 각 조건에 괄호 |
| 원본 수정 의도 없이 직접 대입 | SettingWithCopyWarning | .copy()로 복사 후 작업 |
| 문자열을 숫자처럼 연산 | TypeError | astype() 또는 to_numeric()으로 변환 |
| NaN 비교에 == 사용 | 항상 False | isnull() / notna() 사용 |
| merge 후 컬럼 중복 (_x, _y) | 혼동스러운 컬럼명 | suffixes 지정 또는 필요 컬럼만 선택 후 merge |
| groupby 후 인덱스 혼동 | 그룹 키가 인덱스로 들어감 | .reset_index() 사용 |
| 큰 파일 read_csv 메모리 부족 | MemoryError | dtype 지정, usecols, chunksize 사용 |
20.3 학습 로드맵
| 단계 | 기간 | 학습 내용 | 이 튜토리얼 |
|---|---|---|---|
| Level 1 입문 | 1~2주 | Series, DataFrame, 파일 읽기, 탐색, 인덱싱, 필터링 | Ch.1 ~ Ch.8 |
| Level 2 기초 | 2~4주 | 정렬, 컬럼 관리, 결측치, 타입 변환, 문자열, 날짜 | Ch.9 ~ Ch.14 |
| Level 3 중급 | 1~2개월 | GroupBy, Merge, 피벗, Apply, 메서드 체이닝 | Ch.15 ~ Ch.18 |
| Level 4 실전 | 2~3개월 | 실전 분석 프로젝트, 시각화(Matplotlib/Seaborn), 대용량 처리 | Ch.19 + 추가 학습 |
| Level 5 고급 | 지속 | MultiIndex, Window 함수, 시계열 분석, 성능 최적화, Polars | 추가 학습 |
20.4 마치며
축하합니다! 전 20장의 Pandas 기초 완전정복 튜토리얼을 모두 마치셨습니다.
Pandas는 파이썬 데이터 분석의 핵심 도구입니다. 데이터 사이언스, 머신러닝, 비즈니스 인텔리전스, 마케팅 분석 등 어떤 분야에서든 Pandas를 다룰 수 있으면 업무 효율이 비약적으로 향상됩니다. 이 튜토리얼의 예제를 직접 Jupyter Notebook에서 실행해 보며 반복 학습하세요.
1. 직접 타이핑하세요 — 복사-붙여넣기 대신 직접 입력하면 손이 기억합니다. 오타를 수정하는 과정에서 메서드 이름을 체득합니다.
2. 에러를 두려워하지 마세요 — 에러 메시지를 읽고 해결하는 과정이 가장 효과적인 학습입니다. 특히 Pandas의 SettingWithCopyWarning은 초보자를 가장 많이 괴롭히는 경고인데, 이를 이해하면 한 단계 성장합니다.
3. 나만의 데이터로 분석하세요 — 관심 있는 주제(영화, 음악, 운동 기록, 가계부, 주식 등)의 데이터를 직접 불러와서 분석해 보세요. 실제 문제를 풀면서 배우는 것이 가장 빠릅니다.
데이터 분석의 세계에 오신 것을 환영합니다. 이 튜토리얼이 여러분의 Pandas 여정에 든든한 첫걸음이 되기를 바랍니다!